BFSI v1: blockchain-agnostic pivot, face-first identity, device + signup ceremonies#61
Merged
Conversation
added 6 commits
May 29, 2026 11:35
The docs build (since markdown.format: 'detect' landed in 5ade6e8) surfaced a set of pre-existing broken relative links from prose pages into /adr/. Docusaurus' content plugin scopes the docs root at ../docs/ so `../../adr/0013-…md` resolves outside the served tree and emits a build warning per link. Convert the affected references to absolute GitHub URLs pointed at main, which both render in the docs site and stay clickable in the GitHub source view. Files touched: - docs/compliance/risk/enterprise-risk-register-v1.md - docs/cryptography/trusted-setup-ceremony.md - docs/operations/anchor-bank-demo-runbook.md Verify: npm --prefix website run build → 0 broken-link warnings on these three files (the wider list shrinks accordingly).
Before this commit the dashboard's "Register device" was an admin
typing a free-form name into a modal. A row landed `active`, no
hardware binding, no enrollment ceremony — every device claimed
identity through the shared tenant API key, and any operator could
mint infinite phantom rows. Threat model row A-22 and the Scene 5
attendance-fraud scenario in the BFSI demo runbook were the openers.
Replace with the canonical two-step handshake (Tailscale / Slack /
Cloudflare-Tunnel pattern):
1. Admin creates a pending slot in the dashboard. Server mints a
one-time enrollment code (ZA-XXXX-XXXX, 8 entropy chars from
a 27-symbol Crockford-base32 alphabet, 15-minute TTL). The
plaintext code is returned exactly once; the server keeps only
SHA-256. Row state = pending; status orthogonal.
2. Device POSTs to /v1/devices/enroll with the code + a hardware
fingerprint string (>=16 chars, opaque to server, SHA-256'd
and stored as fingerprint_hash). Server validates the code by
hash lookup + TTL, asserts no collision with another enrolled
row on the same fingerprint, binds the fingerprint, flips the
row to enrolled.
The enrollment endpoint is unauthenticated by design — the code IS
the bearer credential. Brute-force is bounded by SHA-256 lookup
cost + per-IP rate-limit (10 req/min via existing pgRateLimit) +
the 15-minute window. Listed in PUBLIC_ROUTE_EXCEPTIONS for the
same reason /v1/zkp/verify is.
Five new audit actions all route through appendAuditEvent so they
land in the hash chain: device.enrollment_code_issued, _reissued,
device.enrolled, device.revoked, device.created (existing,
metadata.via='trusted-service'). Code and code-hash never appear
in audit metadata.
Schema is additive — six new columns on `devices` via
ADD COLUMN IF NOT EXISTS with backfilled defaults
(enrollment_state='enrolled', device_type='kiosk') so the demo
seed and any prior production data keeps working unchanged.
Backwards compat: POST /v1/devices (tenant API key) keeps its
direct-create semantics for the SDK / bulk-provisioning path and
the demo seed. Dashboard path is the new flow.
Verify:
- npx tsc --noEmit clean
- tests/device-enrollment 26/26
- tests/console-proxy updated to new contract (POST now
requires device_type; DELETE soft-
revokes)
- npm test 500/500 across 43 suites
- ADR 0022 explains state model, code-format math, rate-limit
calibration, attestation roadmap,
and the deferred items (per-device
tokens, QR rendering, bulk CSV,
geofence allowlist).
Closes the dashboard-side fleet-onboarding scene of the BFSI demo.
Companion to the backend handshake. Three new client capabilities
in dashboard/src/lib/api.ts:
- createDevice now takes deviceType (required) and returns the
DeviceEnrollmentInvite envelope: { device, enrollment: { code,
expires_at, deeplink } }. The plaintext code is the operator's
one-time chance to copy.
- regenerateDeviceCode hits POST /api/console/devices/:id/
regenerate-code; re-issues the code on a pending slot.
- revokeDevice hits DELETE /api/console/devices/:id; soft-revoke.
- listDevices accepts an enrollmentState filter and passes it
through to the new ?enrollment_state= query param.
- Device type extended with device_type, enrollment_state,
enrollment_code_expires_at, enrolled_at, fingerprint_hash,
attestation_kind to mirror the server row.
Devices.tsx redesigned:
- Two filter selects: enrollment state (pending/enrolled/revoked)
plus the existing status filter, side by side.
- Table grows a Type column (humanised device_type labels), an
Enrollment column (badge tones: pending=warn, enrolled=success,
revoked=neutral), and a right-aligned Actions column.
- Pending rows surface "Re-issue code" + "Revoke" actions.
Enrolled rows surface "Revoke" only. Revoked rows have no
actions (row stays for audit).
- "Register device" modal now collects name + device_type +
optional location; submit calls createDevice.
- On success, a second modal (EnrollmentInviteModal) shows:
* The plaintext code in big mono type with a CopyButton.
* A live 15-minute countdown synced to expires_at.
* The zeroauth://enroll?code= deeplink (also copyable).
* A short instruction block pointing the operator at the
device's enrollment flow (companion app / kiosk firmware).
The code is unrecoverable after the operator closes the modal —
they re-issue from the device row if it's lost.
Verify:
- npx tsc --noEmit (dashboard/) clean
- npm test (vitest) 56/56
- npm run build (vite) 119 modules, no warnings
V1 deliberately omits QR rendering (would pull in a new dep — see
ADR 0022 §"Out of scope"). The deeplink format is stable so the
QR follow-up can land without changing the server.
The device-enrollment flow (ADR 0022) gave an operator a way to add
a device to a tenant's fleet. This commit adds the missing other
half: the way an actual end-user creates an account on the org's
site using their phone as the biometric credential carrier.
The user described it like this:
- Org implements ZeroAuth instead of Google Sign-In.
- User fills name + email on the org's signup page.
- User scans QR1 on their phone → phone pairs with the session.
- User enrolls biometric on the phone → commitment computed
locally; biometric never leaves the device.
- User scans QR2 on the platform → phone uploads (did, commitment).
- User scans QR3 on the platform → phone re-captures biometric,
produces Groth16 proof, server verifies + creates the account.
Same threat model as WebAuthn registration except the credential
is a biometric (and the proof is zero-knowledge instead of a
signature). Same UX shape as Slack's "approve-from-desktop" flow.
The QR codes are the side channel between the laptop browser and
the phone's camera — no Bluetooth, no custom SDK on the laptop,
no biometric over the wire.
Three single-use SHA-256-hashed codes in three separate columns,
each with its own 15-min TTL. Three corresponding routes on the
phone side, listed in PUBLIC_ROUTE_EXCEPTIONS for the same reason
/v1/devices/enroll is — the code IS the bearer credential. The
chain of three single-use codes provides cross-step confused-deputy
defence (a captured pair_code can't satisfy submit-commitment;
a verify_code can't satisfy pair-device).
State machine on the new registration_sessions table:
awaiting_device → pair_code outstanding, no device yet
awaiting_commitment → device paired, enroll_code outstanding
awaiting_verification → commitment stored, verify_code +
challenge_nonce outstanding
completed → tenant_user created
abandoned → expired or admin-cancelled
Phone-side endpoints (no auth, code is bearer, 20 req/min/IP):
POST /v1/registrations/pair-device
POST /v1/registrations/submit-commitment
POST /v1/registrations/complete
Tenant-side endpoints (tenant API key):
POST /v1/registrations
GET /v1/registrations/:id (redacts code hashes + nonce)
DELETE /v1/registrations/:id
Defence-in-depth: `sanitizeProfile` regex-strips any profile-blob
key matching image/template/pixel/depth/frame/raw_face/raw_finger/
biometric/photo (with word-boundary matching) at ingest, so a buggy
tenant SDK that passes a raw biometric in the profile field gets the
key dropped rather than committed.
V1 limitation documented in ADR 0023: the challenge_nonce binds to
the *request*, not to the proof's public signals (circuit v1.2
doesn't have a slot for it). Replay across sessions is blocked by
the single-use verify_code chain + 15-min TTL + rate-limit; full
circuit-bound binding lands with circuit v1.3 in Phase 1 Sprint 4.
The deeplink format and route surface stay stable across that
upgrade.
Verify:
- npx tsc --noEmit clean
- tests/registration-flow 19/19 (mocked pg pool, no Postgres)
- npm test 524/524 across 44 suites
- ADR 0023 captures the state machine, code-format math,
confused-deputy defence, four threat-model deltas (A-30..A-33),
and the deferred items (QR rendering dep, circuit-bound
challenge, SSE poll-replacement, per-tenant profile schema,
dashboard demo UI).
Closes the end-user signup half of the BFSI demo. The dashboard
demo page that walks an operator through the ceremony with a
simulated phone panel lands in a follow-up commit (mirroring the
shape of demo/QrProofLogin).
ADR 0022 (device enrollment) and ADR 0023 (three-QR signup) both
shipped deeplinks the operator was supposed to render as scannable
QRs but both deferred the QR-rendering dep "to a follow-up commit
with a dep-add ADR" — this is that commit.
Picked qrcode.react@4.2.0 over node-qrcode (3x larger, needs manual
React wrapping), @zxing/library (2.4 MB, includes a full decode
pipeline we don't need), and a vendored 3 kB encoder (smallest but
ongoing maintenance cost exceeds the bundle saving). qrcode.react
is ISC-licensed (functionally MIT), maintained by zpao (former
React core team), zero runtime deps, peer-dep on React ^16-19.
Supply chain: `npm audit --omit=dev` clean. No CVEs against
qrcode.react@4.2.0 in the GitHub Advisory DB or OSV. The two
moderate findings npm audit surfaces (postcss < 8.5.10 XSS, ws
8.0-8.20 memory disclosure) are in dev-only transitive deps used
by vitest + vite — they don't ship to customers.
The dep is used by the new dashboard/src/routes/demo/QrRegistration
page (next commit) and is also available to upgrade the
QrProofLogin demo's block-grid placeholder to a real QR. The
existing copy-the-deeplink UX continues to work as a fallback for
accessibility + headless tests.
Verify:
- tests/device-enrollment 26/26 (unaffected)
- tests/registration-flow 19/19 (unaffected)
- dashboard tests 56/56 (unaffected)
- dashboard build OK; bundle main path
unchanged; qrcode.react
rides the QrRegistration
lazy chunk (~30 kB / ~10 kB
gzip)
End-user-visible counterpart to the ADR 0023 backend. The operator
opens the route, types a name + email, clicks "Open session & mint
QR1", and the page walks through three QR steps:
1. QR1 ("Pair your phone") — encodes the pair-step deeplink. A
phone scanning the QR (or, in this demo, the simulator panel)
POSTs to /v1/registrations/pair-device with the code + a
hardware fingerprint. Server flips state to awaiting_commitment
and mints the enroll_code.
2. QR2 ("Submit your biometric commitment") — the phone captures
the biometric locally (mobile/biometric/ pipeline), computes
the Poseidon commitment + DID, scans QR2 to POST them to
/v1/registrations/submit-commitment. State flips to
awaiting_verification; server mints verify_code + a 128-bit
challenge_nonce baked into QR3's deeplink.
3. QR3 ("Verify and create account") — phone re-captures the
biometric, produces a Groth16 proof, scans QR3 to POST to
/v1/registrations/complete. Server checks the challenge_nonce
matches, asserts publicSignals[0] equals the stored commitment,
verifies the proof off-chain, creates the tenant_user.
The biometric never crosses a wire. Only the commitment (step 2)
and the proof (step 3) do. The deeplink format is
zeroauth://reg?step=<pair|enroll|verify>&session=<uuid>&code=<code>
[&challenge=<hex>] — same format the android/ companion app
handles.
Right column is a "Simulate phone" panel that exercises the
phone-side endpoints directly so an operator can drive the
ceremony from one browser window without an actual companion app.
Pair + commit steps run end-to-end against the live backend. The
verify step intentionally lands a `verify_failed` because the demo
posts a stub Groth16 proof — that's the verifier doing its job. The
real green path goes through the android/ mobile prover (Phase 1
Sprint 4 integration).
Server side: three new console proxies at /api/console/registrations
(POST / GET :id / DELETE :id) mirror /v1/registrations but auth via
console JWT. Both surfaces strip pair_code_hash, enroll_code_hash,
verify_code_hash, verify_challenge_nonce out of the response shape
before it touches the browser — the plaintext codes are returned
only at issuance, the challenge nonce travels only in the QR3
deeplink.
Nav: "QR sign-in" + "QR signup" now sit side by side under the
shared /demo/ namespace; the old singular "Demos" label split to
make the two flows distinguishable.
Verify:
- npx tsc --noEmit (dashboard + backend) clean
- npm test (dashboard, vitest) 56/56
- npm test (backend, jest) 524/524 across 44 suites
- npm run build (dashboard) 121 modules, QrRegistration
lazy chunk = 28.4 kB / 9.95 kB
gzip; main bundle unchanged
🔒 Security review requiredThis PR touches security-sensitive surfaces. Per CLAUDE.md §4, the Touched paths:
How to run the review: Reply on this PR with the structured findings report (or a "no findings" confirmation) before requesting merge. Block merge if any Critical / High finding lands without a tracked carve-out. This comment is posted automatically by |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Sixty-four commits accumulated on
devsince the last sync tomain. Three loosely-stacked themes, each landing on independent ADRs and a closed-loop test surface.1. Phase 0 P0 audit closures
Closes audit-findings tracker rows C-1, C-3, C-7, C-9, C-10, C-11, C-13, C-14, C-15:
submitProof.?access_token=query fallback replaced with HttpOnly cookie.verification_key.jsonagainstEXPECTED_VKEY_SHA256./v1/zkp/verify,/v1/zkp/register,/api/console/login.tenant.security_policy.allowed_origins..github/workflows/cve-monitor.yml.2. Blockchain-agnostic pivot (ADR 0017)
The platform is now off-chain by default. Three opt-in providers keyed on
tenant.security_policy:did_provider:off-chain(default) |base-sepolia|base-mainnet|custom-chainverifier_provider:off-chain(default) |on-chainaudit_anchor_provider:none(default) |signed-transcript|base-sepolia|base-mainnet|witness-cosignA default tenant boots with no
BLOCKCHAIN_PRIVATE_KEY, no contract address, no RPC. The Pramaan ZK protocol + hash-chained audit log work end-to-end off-chain. Auth0 differentiation pitch (docs/why-zeroauth/vs-auth0.md) doesn't depend on any blockchain.3. Face-first identity surface (ADR 0017 + 0018)
Production register + verify endpoints:
POST /v1/identity/register— accepts on-device-computed(did, commitment)only. No template, no image, no embedding ever crosses the wire.POST /v1/identity/verify— looks up by DID, assertspublicSignals[0]matches stored commitment, runssnarkjs.groth16.verifyagainst the boot-pinned vkey, mints session.On-device pipeline lives in
mobile/biometric/: FaceEmbedder (TFLite MobileFaceNet) → Quantizer → SHA-256 → Poseidon → Keccak256-derived DID. Real Poseidon-BN128 vendored fromandroid/sec/Poseidon.kt, byte-identical tocircomlibjs.poseidon2.Legacy
/v1/auth/zkp/*retained withDeprecation: true+Sunset: 2026-12-31headers.4. Production device-enrollment flow (ADR 0022)
Replaces the "type a name and click Register" surface with the canonical two-step handshake. Admin creates a pending slot → server mints a one-time
ZA-XXXX-XXXXcode (SHA-256 stored, 15-min TTL) → device claims viaPOST /v1/devices/enrollwith a hardware fingerprint → row flips toenrolled. Rate-limited 10 req/min per IP.Dashboard
Devices.tsxredesigned: device-type selector (mobile_android / mobile_ios / kiosk / iot_bridge / desktop), pending/enrolled/revoked enrollment-state filter, Re-issue and Revoke row actions, post-create enrollment modal with code + countdown + deeplink.5. Three-QR end-user signup ceremony (ADR 0023)
The user-visible counterpart. Org's signup page calls
POST /v1/registrations→ server returns QR1 → user scans → phone pairs → QR2 → phone uploads(did, commitment)→ QR3 → phone re-captures, produces Groth16 proof, server verifies and createstenant_user. Biometric never crosses a wire.Three single-use codes in three columns (cross-step confused-deputy defence), 15-min per-code TTL, 30-min whole-session TTL, server-issued challenge_nonce baked into QR3, V1 binding via single-use code chain + circuit-bound challenge tracked for Phase 1 Sprint 4.
Dashboard demo at
/demo/registrationwith a "Simulate phone" side panel that exercises the phone-side endpoints from the same browser — drives pair + commit green end-to-end against the live backend, verify intentionally surfacesverify_failedbecause the demo posts a stub proof.6. Supporting work
event_hash = SHA-256(canonical_json(payload) ‖ previous_hash), RFC 8785 JCS, Postgres advisory locks per tenant) + daily on-chain anchor (gated byaudit_anchor_provider).docs/cryptography/trusted-setup-ceremony.md.docs/operations/anchor-bank-demo-runbook.md.docs/compliance/compliance-roadmap-v1.md.docs/compliance/dpdp-2t-commitments-memo-v0.md.docs/compliance/risk/enterprise-risk-register-v1.md.docs/product/bank-intel/.docs/gtm/outreach-sequence-v1.md.markdown.format: 'detect'so.mdfiles parse as CommonMark; security-review workflow grantedissues: writeto fix 404 on PR comment.Diff summary
293 files changed, 44,315 insertions(+), 357 deletions(-).
Test plan
npx tsc --noEmitclean (backend + dashboard)npm test— 524/524 backend across 44 suitesnpm test(dashboard, vitest) — 56/56npm run build:all— backend + dashboard + docs site all green8e39425) — successnpm audit --omit=dev/v1/devices/enroll,/v1/registrations/{pair-device,submit-commitment,complete}) with reason stringsINSERT INTO audit_eventsoutsidesrc/services/audit.ts